iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0
Software Development

文藝復興:這段程式碼,好像有點味道系列 第 15

Day 15|改一個需求,卻要在同一幅畫裡到處補筆:發散式變更 (Divergent Change)

  • 分享至 

  • xImage
  •  

濕壁畫(fresco)有一個殘酷的物理限制:
灰泥抹上牆的那一刻起,趁灰泥還沒乾透之前完成筆觸,畫家只有幾個小時可以動作
灰泥一乾,顏料就再也吃不進去了;畫錯一筆,就沒有回頭路了

1508 年,米開朗基羅接下西斯汀教堂天頂畫的委託
整片天頂,分成幾百個「giornata」意指「一天的量」,指一次施工能完成的灰泥面積

每一個 giornata 動工前,必須先想清楚這一小塊要畫什麼、跟旁邊怎麼銜接
一旦灰泥抹上去,這塊區域的設計,就定案了

模組一談的是「體積太大」,模組二談的是「結構用錯」
模組三談的是完全不同的東西:改一個需求,為什麼要付出遠超預期的代價?

三個「凝固」警訊:

Day Code Smell 一句話定位
15 發散式變更 (Divergent Change) 一個類別,因為好幾個互不相關的理由被頻繁修改
16 霰彈式修改 (Shotgun Surgery) 一個小需求,卻要在好幾個類別裡各補一槍
17 平行繼承體系 (Parallel Inheritance Hierarchies) 兩套繼承結構,像鏡子一樣必須同步增減

第一站,從一塊被迫回應太多種天氣的灰泥開始


同一塊 giornata 上,如果同時被要求:

  • 天氣變了要重新調整灰泥比例
  • 贊助人臨時要求改變聖人手勢
  • 隔壁區塊的顏色需要跟這裡銜接

三件互不相關的事,逼著畫家在同一塊灰泥上,反覆修改

而灰泥的乾燥時間有限,每一次修改都在跟時間賽跑,修改的理由越多元,這塊區域就越危險

訂單處理穩了之後,報表需求來了

模組一到模組二,OrderProcessor 被拆得很乾淨了

團隊接到新需求:「每月要產出一份訂單彙總報表,寄給營運主管」

有人很快寫出了 OrderReport

public class OrderReport
{
    private readonly AppDbContext _db;
    public OrderReport(AppDbContext db) => _db = db;

    public string GenerateMonthlyReport(int year, int month)
    {
        // 職責一:從資料庫撈資料
        var orders = _db.Orders
            .Where(o => o.CreatedAt.Year == year && o.CreatedAt.Month == month)
            .ToList();

        // 職責二:計算業務數字
        decimal totalRevenue = orders.Sum(o => o.Price);
        decimal totalTax = totalRevenue * 0.05m;
        var vipOrders = orders.Where(o => o.CustomerTier == CustomerTier.Vip).Count();

        // 職責三:格式化成 HTML 郵件內容
        var html = $"<h1>{year} 年 {month} 月訂單報表</h1>";
        html += $"<p>總營收:{totalRevenue:C}</p>";
        html += $"<p>營業稅:{totalTax:C}</p>";
        html += $"<p>VIP 訂單數:{vipOrders}</p>";

        return html;
    }
}

能動,也不長,在半年前,這種寫法我們可能不會多想

這個類別,會因為三種完全不相關的理由被打開

問題不在「這個方法太長」,它其實不到 20 行

問題在於 OrderReport 會因為三種互不相關的理由,被反覆修改

  • 資料庫結構改變(例如 Orders 表新增了一個折扣欄位),要改資料撈取的那一段
  • 業務計算規則改變(例如營業稅率調整、VIP 定義變了),要改計算的那一段
  • 輸出格式改變(例如主管要求改成 PDF,或加上公司 Logo),要改格式化的那一段

三條完全不相干的故事線,擠在同一個檔案裡

這跟 Day 04 的巨大類別很像,但判斷的角度不一樣。Day 04 問的是「這個類別做了幾件事」
今天要問的是更精確的問題:「這個類別,會因為幾種不同的理由被修改?」

負責「調整稅率」的人,跟負責「改報表版型」的人,很可能會同時打開同一個檔案,甚至衝突在同一段程式碼附近

三塊灰泥,各自回應一種變化的理由

解法跟 Day 04 一樣是提煉類別 (Extract Class)
但這次的切分依據更明確,每一種「被修改的理由」,都值得有自己的家

public class OrderDataFetcher
{
    private readonly AppDbContext _db;
    public OrderDataFetcher(AppDbContext db) => _db = db;

    public List<Order> FetchByMonth(int year, int month) =>
        _db.Orders.Where(o => o.CreatedAt.Year == year && o.CreatedAt.Month == month).ToList();
}

public class OrderReportCalculator
{
    public ReportSummary Calculate(List<Order> orders) => new ReportSummary
    {
        TotalRevenue = orders.Sum(o => o.Price),
        TotalTax = orders.Sum(o => o.Price) * 0.05m,
        VipOrderCount = orders.Count(o => o.CustomerTier == CustomerTier.Vip)
    };
}

public class OrderReportHtmlFormatter
{
    public string Format(int year, int month, ReportSummary summary) =>
        $"<h1>{year} 年 {month} 月訂單報表</h1>" +
        $"<p>總營收:{summary.TotalRevenue:C}</p>" +
        $"<p>營業稅:{summary.TotalTax:C}</p>" +
        $"<p>VIP 訂單數:{summary.VipOrderCount}</p>";
}

OrderReport 縮回成一個薄薄的協調者:

public class OrderReport
{
    private readonly OrderDataFetcher _fetcher;
    private readonly OrderReportCalculator _calculator;
    private readonly OrderReportHtmlFormatter _formatter;

    public OrderReport(OrderDataFetcher fetcher, OrderReportCalculator calculator,
        OrderReportHtmlFormatter formatter)
    {
        _fetcher = fetcher;
        _calculator = calculator;
        _formatter = formatter;
    }

    public string GenerateMonthlyReport(int year, int month)
    {
        var orders = _fetcher.FetchByMonth(year, month);
        var summary = _calculator.Calculate(orders);
        return _formatter.Format(year, month, summary);
    }
}

現在調稅率的人,只會動到 OrderReportCalculator
改版型的人,只會動到 OrderReportHtmlFormatter

兩個人不會再搶同一段程式碼

半年後主管想加 PDF 輸出,只要新增一個 OrderReportPdfFormatter
OrderDataFetcherOrderReportCalculator,一行都不用動

怎麼判斷「理由」是不是真的不同

不是每一次拆分都值得做,判斷的重點是:

  • 這兩段邏輯,是不是分別由不同角色、不同時間點提出的需求在驅動?
  • 如果只改其中一段,另一段真的完全不受影響嗎?
  • 幫這個類別列出「可能被修改的理由」,會不會列出兩項以上,而且理由之間毫無關聯?

只要答案是肯定的,這個類別大概已經在同一塊灰泥上,被迫回應太多種天氣了

自我檢查清單

  1. 這個類別,我能列出幾種「可能被修改的理由」?
  2. 這些理由,是不是分別來自不同角色(工程、業務、設計)的需求?
  3. 上次修改這個類別,是不是只動了其中一小段,其他段落完全沒碰?
  4. 兩個工程師,會不會因為修改不相關的功能,卻同時打開同一個檔案?
  5. 拆開之後,每個新類別是不是都只回應一種變化的理由?

明日預告

明天我們看發散式變更的鏡像問題:
「不是一個類別因為太多理由被改,是一個單純的需求,卻要在好幾個類別裡各補一槍

模組三第二站:霰彈式修改(Shotgun Surgery)


上一篇
Day 14|達文西退後三步:結構對了,還要整幅畫都對得上
下一篇
Day 16|換一種顏料,整間畫室都要重新調色:霰彈式修改 (Shotgun Surgery)
系列文
文藝復興:這段程式碼,好像有點味道27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言